12  Векторные представления слов

12.1 Векторы в лингвистике

Векторные представления слов - это совокупность подходов к моделированию языка, которые позволяют осуществлять семантический анализ слов и составленных из них документов. Например, находить синонимы и квазисинонимы, а также анализировать значения слов в диахронной перспективе.

В математике вектор – это объект, у которого есть длина и направление, заданные координатами вектора. Мы можем изобразить вектор в двумерном или трехмерном пространстве, где таких координат две или три (по числу измерений), но это не значит, что не может быть 100- или даже 1000-мерного вектора: математически это вполне возможно. Обычно, когда говорят о векторах слов, имеют в виду именно многомерное пространство.

Что в таком случае соответствует измерениям и координатам? Есть несколько возможных решений.

Мы можем, например, создать матрицу термин-документ, где каждое слово “описывается” вектором его встречаемости в различных документах (разделах, параграфах…). Слова считаются похожими, если “похожи” их векторы (о том, как сравнивать векторы, мы скажем чуть дальше). Аналогично можно сравнивать и сами документы.

Второй подход – зафиксировать совместную встречаемость (или другую меру ассоциации) между словами. В таком случае мы строим матрицу термин-термин. За контекст в таком случае часто принимается произвольное контекстное окно, а не целый документ. Небольшое контекстное окно (на уровне реплики) скорее сохранит больше синтаксической информации. Более широкое окно позволяет скорее судить о семантике: в таком случае мы скорее заинтересованы в словах, которые имеют похожих соседей.

И матрица термин-документ, и матрица термин-термин на реальных данных будут длинными и сильно разреженными (sparse), т.е. большая часть значений в них будет равна 0. С точки зрения вычислений это не представляет большой трудности, но может служить источником “шума”, поэтому в обработке естественного языка вместо них часто используют так называемые плотные (dense) векторы. Для этого к исходной матрице применяются различные методы снижения размерности.

В этом уроке мы рассмотрим алгоритм LSA и векторные модели на основе PMI. В первом случае анализируется матрица термин-документ, во втором – матрица термин-термин. Оба подхода предполагают использование SVD.

12.2 SVD

Для любых текстовых данных и матрица термин-термин и матрица термин-документ будет очень разряженной (то есть большая часть значений будет равна нулю). Необходимо “переупорядочить” ее так, чтобы сгруппировать слова и документы по темам и избавиться от малоинформативных тем.

Для этого используется алгебраическая процедура под названием сингулярное разложение матрицы (SVD). При сингулярном разложении исходная матрица \(A_r\) проецируется в пространство меньшей размерности, так что получается новая матрица \(A_k\), которая представляет собой малоранговую аппроксимацию исходной матрицы (К. Маннинг, П. Рагхаван, Х. Шютце 2020, 407).

Для получения новой матрицы применяется следующая процедура. Сначала для матрицы \(A_r\) строится ее сингулярное разложение (Singular Value Decomposition) по формуле: \(A = UΣV^t\) . Иными словами, одна матрица представляется в виде произведения трех других, из которых средняя - диагональная.

Источник: Яндекс Практикум

Здесь U — матрица левых сингулярных векторов матрицы A; Σ — диагональная матрица сингулярных чисел матрицы A; V — матрица правых сингулярных векторов матрицы A. О сингулярных векторах можно думать как о топиках-измерениях, которые задают пространство для наших документов.

Строки матрицы U соответствуют словам, при этом каждая строка состоит из элементов разных сингулярных векторов (на иллюстрации они показаны разными оттенками). Аналогично в V^t столбцы соответствуют отдельным документам. Следовательно, кажда строка матрицы U показывает, как связаны слова с топиками, а столбцы V^T – как связаны топики и документы.

Некоторые векторы соответствуют небольшим сингулярным значениям (они хранятся в диагональной матрице) и потому хранят мало информации, поэтому на следующем этапе их отсекают. Для этого наименьшие значения в диагональной матрице заменяются нулями. Такое SVD называется усеченным. Сколько топиков оставить при усечении, решает человек.

Собственно эмбеддингами, или векторными представлениями слова, называют произведения каждой из строк матрицы U на Σ, а эмбеддингами документа – произведение столбцов V^t на Σ. Таким образом мы как бы “вкладываем” (англ. embed) слова и документы в единое семантическое пространство, число измерений которого будет равно числу сингулярных векторов.

Посмотрим теперь, как SVD применяется при анализе текста.

12.3 Латентно-семантический анализ

LSA (Latent Semantic Analysis), или LSI (Latent Semantic Indexing) – это метод семантического анализа текста, который позволяет сопоставить слова и документы с некоторыми темами (топиками). Слово “latent” (англ. “скрытый”) в названии указывает на то, сами темы заранее не известны, и задача алгоритма как раз и заключается в том, чтобы их выявить.

Создатели метода LSA опираются на основополагающий принцип дистрибутивной семантики, согласно которому смысл слова определяется его контекстами, а смысл предложений и целых документов представляет собой своего рода сумму (или среднее) отдельных слов. Этот принцип является общим для всех векторных моделей.

На входе алгоритм LSA требует матрицу термин-документ. Она может хранить сведения о встречаемости слов в документах, хотя нередко используется уже рассмотренная мера tf-idf. Это связано с тем, что не все слова (даже после удаления стоп-слов) служат хорошими показателями темы: слово “дорожное”, например, служит лучшим показателем темы, чем слово “происшествие”, которое можно встретить и в других контекстах. Tf-idf понижает веса для слов, которые присутствуют во многих документах коллекции. Общий принцип действия алгоритма подробно объясняется на очень простом примере по ссылке; мы же перейдем сразу к анализу реальных данных.

12.3.1 Подгтовка данных

Мы воспользуемся датасетом с подборкой новостей на русском языке (для простоты возьмем из него лишь один год). Файл в формате .Rdata можно скачать в формате .Rdata по ссылке.

library(tidyverse)
load("../data/news.Rdata")

news_2019 |> 
  mutate(text = str_trunc(text, 70))

Добавим id для документов.

news_2019 <- news_2019 |> 
  mutate(id = paste0("doc", row_number()))

Составим список стоп-слов.

library(stopwords)
stopwords_ru <- c(
  stopwords("ru", source = "snowball"),
  stopwords("ru", source = "marimo"),
  stopwords("ru", source = "nltk"), 
  stopwords("ru", source  = "stopwords-iso")
  )

stopwords_ru <- sort(unique(stopwords_ru))
length(stopwords_ru)
[1] 715

Разделим статьи на слова и удалим стоп-слова; это может занять несколько минут.

library(tidytext)
news_tokens <- news_2019 |> 
  unnest_tokens(token, text) |> 
  filter(!token %in% stopwords_ru)

Многие слова встречаются всего несколько раз и для тематического моделирования бесполезны. Поэтому можно от них избавиться.

news_tokens_pruned <- news_tokens |> 
  add_count(token) |> 
  filter(n > 10) |> 
  select(-n)

Также избавимся от цифр, хотя стоит иметь в виду, что их пристутствие в тексте может быть индикатором темы: в некоторых случах лучше не удалять цифры, а, например, заменять их на некую последовательность символов вроде digit и т.п. Токены на латинице тоже удаляем.

news_tokens_pruned <- news_tokens_pruned |> 
  filter(str_detect(token, "[\u0400-\u04FF]")) |> 
  filter(!str_detect(token, "\\d"))
Warning in rm(news_tokens): object 'news_tokens' not found
news_tokens_pruned

Посмотрим на статистику по словам.

news_tokens_pruned |> 
  group_by(token) |> 
  summarise(n = n()) |> 
  arrange(-n)

Этап подготовки данных – самый трудоемкий и не самый творческий, но не стоит им пренебрегать, потому что от этой работы напрямую зависит качество модели.

12.3.2 TF-IDF: опрятный подход

Вместо показателей абсолютной встречаемости при анализе больших текстовых данных применяется tf-idf. Эта статистическая мера не используется, если дана матрица термин-термин, но она хорошо работает с матрицами термин-документ, позволяя повысить веса для тех слов, которые служат хорошими дискриминаторами. Например, “заявил” и “отметил”, хотя это не стоп-слова, могут встречаться в разных темах.

news_counts <- news_tokens_pruned |>
  count(token, id)

news_counts
news_counts |> 
  arrange(id)

Добавляем tf_idf.

news_tf_idf <- news_counts |> 
  bind_tf_idf(token, id, n) |> 
  arrange(tf_idf) |> 
  select(-n, -tf, -idf)

news_tf_idf

12.3.3 DocumentTermMatrix

Посмотрим на размер получившейся таблицы.

object.size(news_tf_idf)
5369712 bytes
format(object.size(news_tf_idf), units = "auto")
[1] "5.1 Mb"

Чтобы вычислить SVD, такую таблицу необходимо преобразовать в матрицу термин-документ. Оценим ее размер:

# число уникальных токенов
m <- unique(news_tf_idf$token) |> 
  length()
m
[1] 6299
# число уникальных документов
n <- unique(news_tf_idf$id) |> 
  length()  
n
[1] 3407
# число элементов в матрице 
m * n
[1] 21460693

Используем специальный формат для хранения разреженных матриц.

dtm <- news_tf_idf |> 
  cast_sparse(token, id, tf_idf)
# первые 10 рядов и 5 столбцов
dtm[1:10, 1:5]
10 x 5 sparse Matrix of class "dgCMatrix"
               doc608     doc1670     doc2170     doc2184     doc2219
ранее     0.003530193 .           .           0.005002585 .          
россии    0.010127611 0.003675658 0.004689633 0.004783897 0.005471238
словам    .           .           0.011776384 .           0.006869557
рублей    0.006686328 .           0.027865190 .           .          
рассказал 0.007250151 .           .           0.010274083 .          
данным    0.007406320 .           .           .           0.012003345
издание   0.007759457 .           .           .           0.012575671
ходе      0.008675929 .           .           .           .          
заявил    0.016729327 .           .           .           .          
частности 0.008860985 .           0.012309349 .           .          

Снова уточним размер матрицы.

format(object.size(dtm), units = "auto")
[1] "3 Mb"

12.3.4 SVD с пакетом irlba

Метод для эффективного вычисления усеченного SVD на больших матрицах реализован в пакете irlba. Возможно, придется подождать ⏳.

library(irlba)
lsa_space<- irlba::irlba(dtm, 50) 

Функция вернет список из трех элементов:

  1. d: k аппроксимированных сингулярных значений;
  2. u: k аппроксимированных левых сингулярных векторов;
  3. v: k аппроксимированных правых сингулярных векторов.

Полученную LSA-модель можно использовать для поиска наиболее близких слов и документов или для изучения тематики корпуса – в последнем случае нас может интересовать, какие топики доминируют в тех или иных документах и какими словами они в первую очередь представлены.

12.3.5 Эмбеддинги слов

Вернем имена рядов матрице левых сингулярных векторов и добавим имена столбцов.

rownames(lsa_space$u) <- rownames(dtm)
colnames(lsa_space$u) <- paste0("dim", 1:50)

Теперь посмотрим на эмбеддинги слов.

word_emb <- lsa_space$u |> 
  as.data.frame() |> 
  rownames_to_column("word") |> 
  as_tibble()

word_emb

Преобразуем наши данные в длинный формат.

word_emb_long <- word_emb |> 
  pivot_longer(-word, names_to = "dimension", values_to = "value") |>
  mutate(dimension = as.numeric(str_remove(dimension, "dim")))
  

word_emb_long

12.3.6 Визуализация топиков

Визуализируем несколько топиков, чтобы понять, насколько они осмыслены.

word_emb_long |> 
  filter(dimension < 10) |> 
  group_by(dimension) |> 
  top_n(10, abs(value)) |> 
  ungroup() |> 
  mutate(word = reorder_within(word, value, dimension)) |> 
  ggplot(aes(word, value, fill = dimension)) +
  geom_col(alpha = 0.8, show.legend = FALSE) +
  facet_wrap(~dimension, scales = "free_y", ncol = 3) +
  scale_x_reordered() +
  coord_flip() +
  labs(
    x = NULL, 
    y = "Value",
    title = "Первые 9 главных компонент за 2019 г.",
    subtitle = "Топ-10 слов"
  ) +
  scale_fill_viridis_c()

12.3.7 Ближайшие соседи

Эмбеддинги можно использовать для поиска ближайших соседей.

library(widyr)

nearest_neighbors <- function(df, feat, doc=F) {
  inner_f <- function() {
    widely(
        ~ {
          y <- .[rep(feat, nrow(.)), ]
          res <- rowSums(. * y) / 
            (sqrt(rowSums(. ^ 2)) * sqrt(sum(.[feat, ] ^ 2)))
          
          matrix(res, ncol = 1, dimnames = list(x = names(res)))
        },
        sort = TRUE
    )}
  if (doc) {
    df |> inner_f()(doc, dimension, value) }
  else {
    df |> inner_f()(word, dimension, value)
  } |> 
    select(-item2)
}
nearest_neighbors(word_emb_long, "сборная")
nearest_neighbors(word_emb_long, "завод")

12.3.8 Похожие документы

Информация о документах хранится в матрице правых сингулярных векторов.

rownames(lsa_space$v) <- colnames(dtm)
colnames(lsa_space$v) <- paste0("dim", 1:50)

Посмотрим на эмбеддинги документов.

doc_emb <- lsa_space$v |> 
  as.data.frame() |> 
  rownames_to_column("doc") |> 
  as_tibble()

doc_emb

Преобразуем в длинный формат.

doc_emb_long <- doc_emb |> 
  pivot_longer(-doc, names_to = "dimension", values_to = "value") |>
  mutate(dimension = as.numeric(str_remove(dimension, "dim")))
  

doc_emb_long

И найдем соседей для произвольного документа.

nearest_neighbors(doc_emb_long, "doc14", doc = TRUE)

Выведем документ 14 вместе с его соседями.

news_2019 |> 
  filter(id %in% c("doc14", "doc392", "doc2043")) |> 
  mutate(text = str_trunc(text, 70)) 

Поздравляем, вы построили свою первую рекомендательную систему 🏺.

12.4 Эмбеддинги на основе PMI-матрицы

Теперь рассмотрим второй способ построения эмбеддингов, когда за основу берется матрица термин-термин.

12.4.1 Скользящее окно

Прежде всего разделим новости на контекстные окна фиксированной величины. Чем меньше окно, тем больше синтаксической информации оно хранит.

library(tidyr)

nested_news <- news_tokens_pruned |> 
  dplyr::select(-topic) |> 
  nest(tokens = c(token))

nested_news
slide_windows <- function(tbl, window_size) {
  skipgrams <- slider::slide(
    tbl, 
    ~.x, 
    .after = window_size - 1, 
    .step = 1, 
    .complete = TRUE
  )
  
  safe_mutate <- safely(mutate)
  
  out <- map2(skipgrams,
              1:length(skipgrams),
              ~ safe_mutate(.x, window_id = .y))
  
  out %>%
    transpose() %>%
    pluck("result") %>%
    compact() %>%
    bind_rows()
}

Деление на окна может потребовать нескольких минут. Чем больше окно, тем больше потребуется времени и тем больше будет размер таблицы.

news_windows <- nested_news |> 
  mutate(tokens = map(tokens, slide_windows, 10L)) %>% 
  unnest(tokens) %>% 
  unite(window_id, id, window_id)

news_windows
load("../data/news_windows.Rdata")

12.4.2 Что такое PMI

Обычная мера ассоциации между словами, которой пользуются лингвисты, — точечная взаимная информация, или PMI (pointwise mutual information). Она рассчитывается по формуле:

\[PMI\left(x;y\right)=\log{\frac{P\left(x,y\right)}{P\left(x\right)P\left(y\right)}}\]

В числителе — вероятность встретить два слова вместе (например, в пределах одного документа или одного «окна» длинной n слов). В знаменателе — произведение вероятностей встретить каждое из слов отдельно. Если слова чаще встречаются вместе, логарифм будет положительным; если по отдельности — отрицательным.

Посчитаем PMI на наших данных, воспользовавшись подходящей функцией из пакета widyr.

library(widyr)
news_pmi  <- news_windows  |> 
  pairwise_pmi(token, window_id)
news_pmi |> 
  arrange(-abs(pmi))

12.4.3 Почему PPMI

В отличие от коэффициента корреляции, например, PMI может варьироваться от \(-\infty\) до \(+\infty\), но негативные значения проблематичны. Они означают, что вероятность встретить эти два слова вместе меньше, чем мы бы ожидали в результате случайного совпадения. Проверить это без огромного корпуса невозможно: если у нас есть \(w_1\) и \(w_2\), каждое из которых встречается с вероятностью \(10^{-6}\), то трудно удостовериться в том, что \(p(w_1, w_2)\) значимо отличается от \(10^{-12}\). Поэтому негативные значения PMI принято заменять нулями. В таком случае формула выглядит так:

\[ PMI\left(x;y\right)=max(\log{\frac{P\left(x,y\right)}{P\left(x\right)P\left(y\right)}},0) \] Для подобной замены подойдет векторизованное условие.

news_ppmi <- news_pmi |> 
  mutate(ppmi = case_when(pmi < 0 ~ 0, 
                          .default = pmi)) 

news_ppmi |> 
  arrange(pmi)

Если мы развернем такую матрицу вширь, то она получится очень разреженной; чтобы получить плотные векторы слов, необходимо прибегнуть к SVD.

12.4.4 SVD на матрице с PPMI

Для этого можно передать тиббл фунции widely_svd() для вычисления сингулярного разложения. Обратите внимание на аргумент weight_d: если задать ему значение FALSE, то вернутся не эмбеддинги, а матрица левых сингулярных векторов:

word_emb <- news_ppmi |> 
  widely_svd(item1, item2, ppmi,
             weight_d = FALSE, nv = 100) |> 
  rename(word = item1) # иначе nearest_neighbors() будет жаловаться
word_emb

12.4.5 Визуализация топиков

Снова визуализируем главные компоненты нашего векторного пространства.

word_emb |> 
  filter(dimension < 10) |> 
  group_by(dimension) |> 
  top_n(10, abs(value)) |> 
  ungroup() |> 
  mutate(word = reorder_within(word, value, dimension)) |> 
  ggplot(aes(word, value, fill = dimension)) +
  geom_col(alpha = 0.8, show.legend = FALSE) +
  facet_wrap(~dimension, scales = "free_y", ncol = 3) +
  scale_x_reordered() +
  coord_flip() +
  labs(
    x = NULL, 
    y = "Value",
    title = "Первые 9 главных компонент за 2019 г.",
    subtitle = "Топ-10 слов"
  ) +
  scale_fill_viridis_c()

12.4.6 Ближайшие соседи

Исследуем наши эмбеддинги, используя уже знакомую функцию, которая считает косинусное сходство между словами.

source("../helper_scripts/nearest_neighbors.R")
word_emb |> 
  nearest_neighbors("сборная")
word_emb |> 
  nearest_neighbors("завод")

12.4.7 2D-визуализации пространства слов

word_emb_mx <- word_emb  |> 
  cast_sparse(word, dimension, value) |> 
  as.matrix()

Для снижения размерности мы используем алгоритм UMAP. В отличие от PCA, он снижает размерность нелинейно, и в этом отношении похож на t-SNE.

library(uwot)
set.seed(02062024)
viz <- umap(word_emb_mx,  n_neighbors = 15, n_threads = 2)

Как видно по размерности матрицы, все наши слова вложены теперь в двумерное пространство.

dim(viz)
[1] 6299    2
tibble(word = rownames(word_emb_mx), 
       V1 = viz[, 1], 
       V2 = viz[, 2]) |> 
  ggplot(aes(x = V1, y = V2, label = word)) + 
  geom_text(size = 2, alpha = 0.4, position = position_jitter(width = 0.5, height = 0.5)) +
   annotate(geom = "rect", ymin = 2.5, ymax = 7, xmin = 1.5, xmax = 6.5, alpha = 0.2, color = "tomato")+
  theme_light()

Посмотрим на выделенный фрагмент этой карты.

tibble(word = rownames(word_emb_mx), 
       V1 = viz[, 1], 
       V2 = viz[, 2]) |> 
  filter(V1 > 1.5 & V1 < 6.5) |> 
  filter(V2 > 2.5 & V2 < 7) |> 
  ggplot(aes(x = V1, y = V2, label = word)) + 
  geom_text(size = 2, alpha = 0.4, position = position_jitter(width = 0.5, height = 0.5)) +
  theme_light()

Отличная работа 🏈 Позже мы научимся строить векторное пространство с использованием поверхностных нейросетей.